// AspHardware.cs
//
// Implements HomeUX.Components.AspHardware.
//

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Text.RegularExpressions;
using HomeUX.Utilities;

namespace HomeUX.Components
{

/// <summary>
/// Controls one or more Sound Control Technologies ASP Audio Matrix 8x8 devices connected together
/// to implement an N-input, N-output audio router.  Compared to <r>AspDriver</r>,
/// <n>AspHardware</n> provides a lower-level hardware-specific interface.
/// </summary>
///
/// <remarks>
///
/// <para>Only a subset of the full functionality of the hardware can be accessed by this class.
/// </para>
///
/// <para>General usage pattern:</para>
///
/// <list type="number">
/// 	<item><description>Construct an instance of this class.</description></item>
///		<item><description>Hook up events.</description></item>
///		<item><description>Call <s>SimpleSerialHardware{}.Open</s> (inherited from
///			<r>SimpleSerialHardware{}</r>).</description></item>
///		<item><description>To control the hardware, you can use
/// 		<s>SimpleSerialHardware{}.TransmitCommand</s> (inherited from
///			<r>SimpleSerialHardware{}</r>) to send a raw command to the serial port, but in general
///			it's easier to use higher-level control methods like <r>SetOutputGain</r> and
/// 		<r>SetCrosspointGain</r>.  These methods construct and send a serial command and, when
/// 		the reply is received, they parse the hardware's reply and call a callback method you
/// 		provide, providing the parsed reply object as an argument.</description></item>
/// </list>
///
/// <para>Philosophy about exceptions:</para>
///
/// <list type="bullet">
///		<item><description>Programmer errors can lead to exceptions like <n>ArgumentException</n>.
/// 		</description></item>
///		<item><description>Hardware problems (including timeouts) won't lead to exceptions, but
///			rather will cause the following to occur:
///			<list type="bullet">
///				<item><description>The callback method you provided (in the "onComplete" parameter)
///					will be called with <n>null</n> in place of the parsed reply object.  This lets
/// 				you clean up your state.</description></item>
///				<item><description>Events such as <r>CommandFailed</r> and <r>WarningsReported</r>
///					will be fired as appropriate.  This lets you centralize reporting of hardware
///					problems to the user.</description></item>
///			</list>
///			</description></item>
///	</list>
///
/// </remarks>
///
[DebuggerDisplay("AspHardware {HardwareLabel}")]
public class AspHardware : SimpleSerialHardware<string[]>
{
    ///////////////////////////////////////////////////////////////////////////////////////////////
    // Private Constants
    //

    /// <summary>
    /// The message to display when the hardware sends something unexpected via the serial port.
    /// </summary>
    const string UnexpectedOutputMessage = "Unexpected output from hardware: {0}";

    /// <summary>
    /// Matches the output from a "MASTER" command.
    /// </summary>
    static readonly Regex MasterReplyRegex = new Regex("^MASTER=([0-9]+),([0-9.M]+)$");

    /// <summary>
    /// Matches the output from a "GAIN" command.
    /// </summary>
    static readonly Regex GainReplyRegex = new Regex("^GAIN=([0-9]+),([0-9.M]+),([0-9.M]+)$");

    /// <summary>
    /// The number of ASP 8x8 units.
    /// </summary>
    int _unitCount;

    /// <summary>
    /// The number of input and output channels.
    /// </summary>
    int _channelCount;

    ///////////////////////////////////////////////////////////////////////////////////////////////
    // Public Events
    //

    /// <summary>
    /// Fired when a command fails.
    /// </summary>
    public event EventHandler<AspHardwareCommandFailedEventArgs> CommandFailed;

    /// <summary>
    /// Fired when notification(s) are received.  Currently this class doesn't process
	/// notifications -- they're simply passed as-is to the application.  These notifications can
	/// be displayed to the user, though the user may not know how to interpret them, so in that
	/// case they're mostly for support purposes.
    /// </summary>
    public event EventHandler<AspHardwareNotificationsReceivedEventArgs> NotificationsReceived;

    /// <summary>
    /// Fired when this class has warning(s) to report to the application.  These warnings can be
	/// displayed to the user.
    /// </summary>
    public event EventHandler<AspHardwareWarningsReportedEventArgs> WarningsReported;

    ///////////////////////////////////////////////////////////////////////////////////////////////
    // Public Methods & Related Private Methods
    //

    /// <summary>
    /// Initializes an instance of this class.
    /// </summary>
    ///
    /// <param name="workQueue">The <r>WorkQueue</r> to use for scheduling asynchronous tasks.
	/// 	</param>
    ///
    /// <param name="serialPort">The serial port (real or virtual) to connect to.</param>
    ///
    /// <param name="hardwareLabel">A label to use for referring to this hardware device within
	/// 	warnings, trace output, etc.</param>
    ///
    /// <param name="unitCount">The number of Sound Control Technologies ASP Audio Matrix 8x8
	/// 	devices connected together.</param>
    ///
    public AspHardware(WorkQueue workQueue, VirtualSerialPort serialPort, string hardwareLabel,
            int unitCount) :
        base(workQueue, serialPort, SimpleSerialHardwareMode.Line, hardwareLabel)
    {
        serialPort.NewLine = "\r";
        _unitCount = unitCount;
        _channelCount = unitCount * 8;
    }

    /// <summary>
    /// Retrieves the complete status of the hardware.
    /// </summary>
	///
    /// <param name="onComplete">Called on completion of this method.</param>
	///
    public void GetFullStatus(Action<AspGetFullStatusReply> onComplete)
    {
		// send a STATUS2 command to the hardware
        TransmitCommand("STATUS2", delegate(string[] innerReply)
        {
            // if the STATUS2 command failed (e.g. due to timeout), report it as complete but with
            // null status
            if (innerReply == null)
            {
                onComplete(null);
                return;
            }

            // keep track of accumulated reply
            var reply = new AspGetFullStatusReply()
            {
                CrosspointGains = new decimal[_channelCount][]
            };

            // keep track of warnings
            LazyStringList warnings = new LazyStringList();

			// interpret lines of the reply
            foreach (string line in innerReply)
            {
                // example of <line>: "ALLGAIN=17,,,,,,,1,,,,1,,,,,,,,,,,,,"
                string[] lineParts = line.Split('=');
                if (lineParts.Length == 2)
                {
                    string statusType = lineParts[0]; // e.g. "ALLGAIN"
                    string statusInfo = lineParts[1]; // e.g. "17,,,,,,,1,,,,1,,,,,,,,,,,,,"
                    switch (statusType)
                    {
                    case "ALLGAIN":

                        ParseStatus2AllGainLine(line, statusInfo, reply, warnings);
                        break;

                    case "ALLMASTER":

                        ParseStatus2AllMaster(line, statusInfo, reply, warnings);
                        break;
                    }
                }
                else
                    warnings.Add("Bad STATUS2 command output: {0}", line);
            }

            // ensure that one ALLGAIN line was reported for each input channel
            for (int inputNumber = 1; inputNumber <= _channelCount; inputNumber++)
            {
                if (reply.CrosspointGains[inputNumber - 1] == null)
                    warnings.Add("STATUS2 command output omitted ALLGAIN={0},...", inputNumber);
            }

            // ensure that an ALLMASTER= line was reported
            if (reply.OutputGains == null)
                warnings.Add("STATUS2 command output omitted ALLMASTER=...");

            // report warnings, if any, back to the application
            ReportWarnings(warnings);

            // tell the application that we're done
            onComplete(reply);
        });
    }

    /// <summary>
	/// Sets the output gain (volume level) of a given output channel.
    /// </summary>
    ///
    /// <param name="outputNumber">The output channel number, from 1 to the number of channels,
	/// 	inclusive.</param>
    ///
    /// <param name="gain">The decibel gain level to use.  0 is the maximum volume; positive
	/// 	numbers decrease the volume level.  Consult the hardware documentation for details.
	/// 	Use -1 to mute the channel.</param>
    ///
    /// <param name="onComplete">Called on completion of this method.</param>
    ///
    public void SetOutputGain(int outputNumber, decimal gain,
		Action<AspSetOutputGainReply> onComplete)
    {
		// validate arguments
        if ((outputNumber < 1) || (outputNumber > _channelCount))
            throw new ArgumentOutOfRangeException("outputNumber", "outputNumber is out of range");

		// send a MASTER command to the hardware
        string command = String.Format(CultureInfo.InvariantCulture,
			"MASTER={0},{1}", outputNumber, FormatGain(gain));
        TransmitCommand(command, delegate(string[] innerReply)
        {
            // if the MASTER command failed (e.g. due to timeout), report it as complete but with
            // null reply
            if (innerReply == null)
            {
                onComplete(null);
                return;
            }

            // keep track of warnings
            LazyStringList warnings = new LazyStringList();

            // create the reply
            AspSetOutputGainReply reply = null;
            try
            {
				// we expect a 1-line reply
                if (innerReply.Length != 1)
                    throw new WarningException("Expected 1 line, got {0}", innerReply.Length);

				// parse the reply line
                Match match = MasterReplyRegex.Match(innerReply[0]);
                if (!match.Success)
                    throw new WarningException("Invalid reply: {0}", innerReply[0]);

				// interpret the parsed reply
                try
                {
					// check that output number in the reply matches what we sent to the hardware
                    int reportedOutputNumber = ParseChannelNumber(match.Groups[1].Value);
                    if (reportedOutputNumber != outputNumber)
                    {
                        throw new WarningException(
                            "Reported output number, {0}, doesn't match specified output number, {1}",
                            reportedOutputNumber, outputNumber);
                    }

					// success -- construct a reply to return to the application
                    reply = new AspSetOutputGainReply()
                    {
                        OutputGain = ParseGain(match.Groups[2].Value)
                    };
                }
                catch (WarningException ex)
                {
                    throw new WarningException(
						"Invalid reply \"{0}\": {1}", innerReply[0], ex.Message);
                }
			}
			catch (WarningException ex)
			{
				warnings.Add("Problem with \"{0}\" command: {1}", command, ex.Message);
			}
            finally
            {
                // report warnings, if any, back to the application
                ReportWarnings(warnings);

                // tell the application that we're done
                onComplete(reply);
            }
        });
    }

    /// <summary>
	/// Changes the connection between a given input channel and a given output channel.
    /// inputNumber and outputNumber are in range 1 to number of channels; gain is -1 for mute
    /// </summary>
    /// 
    /// <param name="inputNumber">The input channel number, from 1 to the number of channels,
	/// 	inclusive.</param>
    /// 
    /// <param name="outputNumber">The output channel number, from 1 to the number of channels,
	/// 	inclusive.</param>
    /// 
    /// <param name="gain">The decibel gain level to use for the signals sent from the input
	/// 	channel to the output channel.  0 is the maximum volume; positive numbers decrease the
	/// 	volume level.  Consult the hardware documentation for details.  Use -1 to remove the
	/// 	connection from the input channel to the output channel.</param>
    /// 
    /// <param name="onComplete">Called on completion of this method.</param>
    /// 
    public void SetCrosspointGain(int inputNumber, int outputNumber, decimal gain,
		Action<AspSetCrosspointGainReply> onComplete)
    {
		// validate arguments
        if ((inputNumber < 1) || (inputNumber > _channelCount))
            throw new ArgumentOutOfRangeException("inputNumber", "inputNumber is out of range");
        if ((outputNumber < 1) || (outputNumber > _channelCount))
            throw new ArgumentOutOfRangeException("outputNumber", "outputNumber is out of range");

		// send a GAIN command to the hardware
        string command = String.Format(CultureInfo.InvariantCulture, "GAIN={0},{1},{2}",
			inputNumber, outputNumber, FormatGain(gain));
        TransmitCommand(command, delegate(string[] innerReply)
        {
            // if the GAIN command failed (e.g. due to timeout), report it as complete but with
            // null reply
            if (innerReply == null)
            {
                onComplete(null);
                return;
            }

            // keep track of warnings
            LazyStringList warnings = new LazyStringList();

            // create the reply
            AspSetCrosspointGainReply reply = null;
            try
            {
				// we expect a 1-line reply
                if (innerReply.Length != 1)
                    throw new WarningException("Expected 1 line, got {0}", innerReply.Length);

				// parse the reply line
                Match match = GainReplyRegex.Match(innerReply[0]);
                if (!match.Success)
                    throw new WarningException("Invalid reply: {0}", innerReply[0]);

				// interpret the parsed reply
                try
                {
					// check that input number in the reply matches what we sent to the hardware
                    int reportedInputNumber = ParseChannelNumber(match.Groups[1].Value);
                    if (reportedInputNumber != inputNumber)
                    {
                        throw new WarningException(
                            "Reported input number, {0}, doesn't match specified input number, {1}",
                            reportedInputNumber, inputNumber);
                    }

					// check that output number in the reply matches what we sent to the hardware
                    int reportedOutputNumber = ParseChannelNumber(match.Groups[2].Value);
                    if (reportedOutputNumber != outputNumber)
                    {
                        throw new WarningException(
                            "Reported output number, {0}, doesn't match specified output number, {1}",
                            reportedOutputNumber, outputNumber);
                    }

					// success -- construct a reply to return to the application
                    reply = new AspSetCrosspointGainReply()
                    {
                        CrosspointGain = ParseGain(match.Groups[3].Value)
                    };
                }
                catch (WarningException ex)
                {
                    throw new WarningException(
						"Invalid reply \"{0}\": {1}", innerReply[0], ex.Message);
                }

			}
			catch (WarningException ex)
			{
				warnings.Add("Problem with \"{0}\" command: {1}", command, ex.Message);
			}
            finally
            {
                // report warnings, if any, back to the application
                ReportWarnings(warnings);

                // tell the application that we're done
                onComplete(reply);
            }
        });
    }

    ///////////////////////////////////////////////////////////////////////////////////////////////
    // SimpleSerialHardware Overrides & Related Methods
    //

    /// <summary>
    /// Overrides <r>SimpleSerialHardware{}.ParseReceivedString</r>.
    /// </summary>
    ///
    /// <param name="received">See <r>SimpleSerialHardware{}.ParseReceivedString</r>.</param>
    /// 
    /// <param name="pushBack">See <r>SimpleSerialHardware{}.ParseReceivedString</r>.</param>
    /// 
    /// <param name="currentCommand">See <r>SimpleSerialHardware{}.ParseReceivedString</r>.</param>
    /// 
    /// <param name="onReply">See <r>SimpleSerialHardware{}.ParseReceivedString</r>.</param>
    ///
    protected override void ParseReceivedString(string received, ref string pushBack,
        string currentCommand, Action<string[]> onReply)
    {
        // Characters sent to the device hardware are echoed back, converted to uppercase.  When a
		// CR that terminates a command is sent to the hardware:
		//
        //   -- If the command succeeds, the reply from the hardware is "<CR><LF><command-reply>@",
		//      where <command-reply> will consist of one or more CRLF-terminated lines.  Examples
		//      of <command-reply>:
		//        -- For the command "MASTER=1,M", <command-reply> is the same: "MASTER=1,M".
		//        -- For the command "STATUS2", <command-reply> is a series of CRLF-terminated
		//           lines like "ALLGAIN=5,1,,,,,,,,1,,,,,,,,,,,,,,," followed by an empty
		//           CRLF-terminated line.
		//
        //   -- If the command fails, "?" is sent back, without a CRLF.
        //
        // Notifications are of the form "!<notification><CR><LF>".  Examples:
        //   !FAIL=3	<-- unit 3 failed
        //   !GOOD=3	<-- unit 3 is working again
        //
        // How a string received from the serial port is handled:
        //
        //   -- The contents of <pushBack> -- which contain previously-received
        //      characters that we couldn't process yet (because we're waiting for additional
        //      characters) -- are moved to the beginning of the received string.
		//
        //   -- Notifications can presumably be sent at any time, including in the middle of
        //      echoed command characters.  However, presumably the hardware will send an entire
        //      notification, including CRLF, without any embedded echoed command characters.
        //      Therefore, the first thing we do with a received string is to search for
		//      occurrences of "!<notification><CR><LF>", remove them from the string, and store
		//      them away.  If a "!" is not followed eventually by CRLF everything starting from
		//      the "!" must be ignored and returned to <pushBack>.  The remaining
		//      steps apply to what's left of the received string (if any).
		//
        //   -- If we're waiting for a command reply, i.e. if <_currentHardwareCommand> is not null,
		//      we search the received string for the first occurrence of either "<echo><CR><LF>"
		//      (indicating success) or "<echo>?" (indicating failure), where <echo> is the
		//      uppercase version of <_currentHardwareCommand>.  Characters preceding the echoed
		//      characters are reported as unexpected hardware output but otherwise ignored.
		//
		//        -- In the success case, we search the received string after "<echo><CR><LF>" for
		//           "<CR><LF>@".  (If not found, we return the received string to
		//           <pushBack>.)  Everything up to and including that "<CR><LF>" is
		//           considered <command-reply> (see above); everything after "@" is reported as
		//           unexpected hardware output and ignored.
		//           
		//        -- In the failure case, everything after "?" is reported as unexpected hardware
		//           output and ignored.
		//
		//        -- If we find neither success or failure case, we return the received string to
		//           <pushBack>.
		//

        // I've seen cases of "\r" not followed by "\n", so we remove all "\r" and, below, we just
        // look for "\n" instead of CRLF
        received = received.Replace("\r", String.Empty);

        //   -- Notifications can presumably be sent at any time, including in the middle of
        //      echoed command characters.  However, presumably the hardware will send an entire
        //      notification, including newline, without any embedded echoed command characters.
        //      Therefore, the first thing we do with a received string is to search for
        //      occurrences of "!<notification>\n", remove them from the string, and store
        //      them away.
        LazyStringList notifications = new LazyStringList();
        var notificationRegex = new Regex(@"!([^!\n]*)\n");
        received = notificationRegex.Replace(received, delegate(Match match)
        {
            notifications.Add(match.Groups[1].Value);
            return String.Empty;
        });

        // If a "!" is not followed eventually by newline everything starting from the "!" must be
		// ignored and returned to <pushBack>.  The remaining steps apply to what's left of the
		// received string (if any).
        int index = received.IndexOf('!');
        if (index >= 0)
        {
            pushBack = received.Substring(index);
            received = received.Substring(0, index);
            // Consider: what happens if the newline is lost somehow?  Then,
            // <pushBack> will start with "!" and so the reply from a subsequent
            // command would get "stuck", i.e. considered part of an incomplete notification.
            // To mitigate this, when a command times out, <pushBack> is cleared.
        }

        // <commandError> will be set to true if the hardware reported an error with the command
        bool commandError = false;

        // <commandReply> will be set to the collection of lines (not including trailing newline)
        // that comprise the hardware's successful reply to the command; this will remain null if
        // we haven't received a reply
        string[] commandReply = null;

        // if we come across miscellaneous issue(s) to report (e.g. the driver sends text we
		// don't expect), <warnings> will be set to the issue(s) to report, otherwise it
		// will be null
        LazyStringList warnings = new LazyStringList();

        // if this driver previously sent a command to the hardware, look for the reply now
        if (currentCommand != null)
        {
			// search the received string for the first occurrence of either "<echo>\n"
			// (indicating success) or "<echo>?" (indicating failure), where <echo> is the
			// uppercase version of <currentCommand>
			string echo = currentCommand.ToUpper();
			string find = echo + "\n";
			index = received.IndexOf(find);
			if (index >= 0)
			{
				// this is the success case -- the hardware received the command and echoed it
				// back with a terminating newline, indicating that the hardware understood the
				// command -- the next text from the hardware will be its reply to the
				// command...

				// everything before the echoed command is reported as unexpected hardware
				// output and ignored
				if (index > 0)
				{
					warnings.Add(String.Format(UnexpectedOutputMessage,
						received.Substring(0, index)));
				}

				// the reply from the hardware is terminated by "\n@"; the reply itself
				// consists of one or more newline-separated lines
				int replyIndex = index + find.Length;
				string findEnd = "\n@";
				int replyEndIndex = received.IndexOf(findEnd, replyIndex);
				if (replyEndIndex >= 0)
				{
					// "\n@" was found, so everyting up to and including that newline is
					// considered <command-reply> (see above)
					int replyLength = replyEndIndex - replyIndex;
					string reply = received.Substring(replyIndex, replyLength);
					commandReply = reply.Split(new char[] { '\n' },
						StringSplitOptions.RemoveEmptyEntries);

					// everything after "@" is reported as unexpected hardware output and ignored
					int unexpectedIndex = replyEndIndex + findEnd.Length;
					int unexpectedLength = received.Length - unexpectedIndex;
					if (unexpectedLength > 0)
					{
						warnings.Add(String.Format(UnexpectedOutputMessage,
							received.Substring(unexpectedIndex, unexpectedLength)));
					}

                    // update driver state to indicate that we're no longer executing a command
                    EndCommand();
                }
				else
				{
					// "\n@" was not found, which means we haven't received the complete
					// reply yet, so we return the received string to <pushBack>
					pushBack = received + pushBack;
				}
			}
			else
			{
				find = echo + "?";
				index = received.IndexOf(find);
				if (index >= 0)
				{
					// this is the failure case -- the hardware received the command and
					// echoed it back with a terminating "?", indicating that the command was
					// invalid or not understood...

					// report the failure of the command previously sent to the hardware
					commandError = true;

					// everything before the echoed command is reported as unexpected hardware
					// output and ignored
					if (index > 0)
					{
						warnings.Add(String.Format(UnexpectedOutputMessage,
							received.Substring(0, index)));
					}

					// everything after "?" is reported as unexpected hardware output and ignored
					int unexpectedIndex = index + find.Length;
					int unexpectedLength = received.Length - unexpectedIndex;
					if (unexpectedLength > 0)
					{
						warnings.Add(String.Format(UnexpectedOutputMessage,
							received.Substring(unexpectedIndex, unexpectedLength)));
					}

                    // update driver state to indicate that we're no longer executing a command
                    EndCommand();
                }
				else
				{
					// we haven't found the echoed command yet -- we return the received
					// string to <pushBack>
					pushBack = received + pushBack;
				}
			}
        }

        // We've finished processing the text received from the hardware, and we've updated driver
        // serial communication state (i.e. <pushBack>) and filled in these variables:
		//   -- <notifications> = asynchronous notifications from the driver (e.g. "FAIL=3"),
		//      or null if none.
        //   -- <commandError> = true if the driver reported an error (e.g. "FOO?" in response to
		//      invalid command "FOO").
        //   -- <commandReply> = lines of command reply, e.g. "ALLGAIN=5,1,,,,,,,,1,,,,,,,,,,,,,,,"
		//      (and lines like it) from a "STATUS2" command, or null if a complete reply hasn't
        //      been received yet or there was no current hardware command.
		//   -- <warnings> = issues to report to the user
        //
        // Note that if a reply from a hardware command was fully received,
        // <currentCommand> will be null at this point, indicating that the hardware is
        // ready to accept a new command.
		//

        // report a failed command, if that happened
        if (commandError)
        {
            CommandFailed(this, new AspHardwareCommandFailedEventArgs()
            {
                Command = currentCommand
            });
        }

        // report notifications, if any
		ReportNotifications(notifications);

        // report warnings, if any
		ReportWarnings(warnings);

        // if there was a pending command that completed, call the command's callback function
        if (commandError)
            onReply(null);
        else
        if (commandReply != null)
            onReply(commandReply);
    }

    /// <summary>
    /// Fires <r>NotificationsReceived</r> as needed to inform the application about notifications
	/// in a given list.
    /// </summary>
    /// 
    /// <param name="notifications">The notifications, if any.</param>
    /// 
	public void ReportNotifications(LazyStringList notifications)
	{
        if ((notifications.Count > 0) && (NotificationsReceived != null))
        {
            NotificationsReceived(this, new AspHardwareNotificationsReceivedEventArgs()
            {
                Notifications = notifications.ListOrNull
            });
        }
	}

    /// <summary>
    /// Fires <r>WarningsReported</r> as needed to inform the application about warnings in a given
	/// list.
    /// </summary>
    ///
    /// <param name="warnings">The warnings, if any.</param>
    ///
	public void ReportWarnings(LazyStringList warnings)
	{
        if ((warnings.Count > 0) && (WarningsReported != null))
        {
            WarningsReported(this, new AspHardwareWarningsReportedEventArgs()
            {
                Messages = warnings.ListOrNull
            });
        }
	}

    /// <summary>
    /// Parses an "ALLGAIN=..." line sent from the hardware in response to a STATUS2 request.
    /// </summary>
    /// 
    /// <param name="line">The complete line, starting with "ALLGAIN=".</param>
    /// 
    /// <param name="statusInfo">The portion of the line that follows "ALLGAIN=".</param>
    /// 
    /// <param name="reply">The <r>AspGetFullStatusReply</r> object that will be returned to the
	/// 	application when processing of the STATUS2 request is complete.</param>
    /// 
    /// <param name="warnings">Where to write warnings to report to the user.</param>
    /// 
    void ParseStatus2AllGainLine(string line, string statusInfo, AspGetFullStatusReply reply,
        LazyStringList warnings)
    {
        // <statusInfo> is e.g. "17,,,,,,,1,,,,1,,,,,,,,,,,,,"; first part is the input number,
        // remaining parts are the decibel gain levels for each output, or blank if there's
        // no connection
        try
        {
			// split <statusInfo> into its parts (i.e. gain levels for each output), stored in
			// <statusInfoParts>
            string[] statusInfoParts = statusInfo.Split(',');
            if (statusInfoParts.Length - 1 != _channelCount)
            {
                throw new WarningException("Expected {0} channels, found {1}",
                    _channelCount, statusInfoParts.Length - 1);
            }

			// parse <statusInfoParts>
            int inputNumber = ParseInt(statusInfoParts[0], 1, _channelCount);
            if (reply.CrosspointGains[inputNumber - 1] != null)
                throw new WarningException("Duplicate ALLGAIN={0},...", inputNumber);
            decimal[] gains = new decimal[_channelCount];
            reply.CrosspointGains[inputNumber - 1] = gains;
            for (int outputNumber = 1; outputNumber <= _channelCount; outputNumber++)
            {
                string gainString = statusInfoParts[outputNumber];
                decimal gain;
                if (gainString.Length == 0)
                    gain = -1; // means "not connected"
                else
                    gain = ParseGain(gainString);
                gains[outputNumber - 1] = gain;
            }
        }
        catch (WarningException ex)
        {
            warnings.Add("Problem parsing STATUS2 command output line \"{0}\": {1}", line,
				ex.Message);
        }
    }

    /// <summary>
    /// Parses an "ALLMASTER=..." line sent from the hardware in response to a STATUS2 request.
    /// </summary>
    /// 
    /// <param name="line">The complete line, starting with "ALLMASTER=".</param>
    /// 
    /// <param name="statusInfo">The portion of the line that follows "ALLMASTER=".</param>
    /// 
    /// <param name="reply">The <r>AspGetFullStatusReply</r> object that will be returned to the
	/// 	application when processing of the STATUS2 request is complete.</param>
    /// 
    /// <param name="warnings">Where to write warnings to report to the user.</param>
    /// 
    void ParseStatus2AllMaster(string line, string statusInfo, AspGetFullStatusReply reply,
        LazyStringList warnings)
    {
        // line is e.g. "ALLMASTER=12.5,12.5,,,,,7,7,9.5,,7,7,,,,,,,,,,,,"
        try
        {
			// split <statusInfo> into its parts (i.e. gain levels for each output), stored in
			// <statusInfoParts>
            string[] statusInfoParts = statusInfo.Split(',');
            if (statusInfoParts.Length != _channelCount)
            {
                throw new WarningException("Expected {0} channels, found {1}",
                    _channelCount, statusInfoParts.Length);
            }

			// parse <statusInfoParts>
            if (reply.OutputGains != null)
                throw new WarningException("Duplicate ALLMASTER=...");
            reply.OutputGains = new decimal[_channelCount];
            for (int outputNumber = 1; outputNumber <= _channelCount; outputNumber++)
            {
                string gainString = statusInfoParts[outputNumber - 1];
                decimal gain;
                if (gainString.Length == 0)
                    gain = -1; // means "mute"
                else
                    gain = ParseGain(gainString);
                reply.OutputGains[outputNumber - 1] = gain;
            }
        }
        catch (WarningException ex)
        {
            warnings.Add("Problem parsing STATUS2 command output line \"{0}\": {1}",
				line, ex.Message);
        }
    }

    /// <summary>
    /// Converts a decibel gain level to the format accepted by the hardware.
    /// </summary>
    /// 
    /// <param name="gain">The decibel gain level.  0 is the maximum volume; positive numbers
	/// 	decrease the volume level.  Consult the hardware documentation for details.  Use -1 to
	/// 	to represent "mute".</param>
    /// 
    static string FormatGain(decimal gain)
    {
        if (gain < 0)
            return "M";
        else
            return gain.ToString("n1", CultureInfo.InvariantCulture);
    }

    /// <summary>
	/// Parses a channel number.
    /// </summary>
    ///
    /// <param name="str">The string to parse.</param>
	///
	/// <exception cref="WarningException">
	/// <pr>str</pr> isn't a valid channel number.
	/// </exception>
    ///
    int ParseChannelNumber(string str)
    {
        return ParseInt(str, 1, _channelCount);
    }

    /// <summary>
	/// Parses an integer, and verifies that it's within a specified range.
    /// </summary>
    /// 
    /// <param name="str">The string to parse.</param>
    /// 
    /// <param name="min">The minimum allowed value.</param>
    /// 
    /// <param name="max">The maximum allowed value.</param>
	///
	/// <exception cref="WarningException">
	/// <pr>str</pr> isn't a integer, or isn't within the specified range.
	/// </exception>
    /// 
    int ParseInt(string str, int min, int max)
    {
		// parse <str> into <result>
        int result;
        try
        {
            result = int.Parse(str, CultureInfo.InvariantCulture);
        }
        catch (Exception ex)
        {
            if ((ex is FormatException) || (ex is OverflowException))
                throw new WarningException("Expected an integer, got \"{0}\"", str);
            else
                throw;
        }

		// check that <result> is within the specified range
        if ((result < min) || (result > max))
        {
            throw new WarningException(
				"Expected an integer in the range {0} to {1} (inclusive), got {2}",
                min, max, result);
        }

        return result;
    }

    /// <summary>
    /// Parses a decibel gain value represented as a string.
    /// </summary>
    ///
    /// <param name="str">The string to parse.  Note that if <pr>str</pr> is "M", it's interpreted
	/// 	as "mute", and -1 is returned.</param>
	///
	/// <exception cref="WarningException">
	/// <pr>str</pr> isn't a valid decibel value, or isn't within the specified range.
	/// </exception>
    ///
    decimal ParseGain(string str)
    {
        if (str == "M")
            return -1;
        else
            return ParseDecimal(str, 0, decimal.MaxValue);
    }

    /// <summary>
	/// Parses a decimal value, and verifies that it's within a specified range.
    /// </summary>
    /// <param name="str">The string to parse.</param>
    /// 
    /// <param name="min">The minimum allowed value.</param>
    /// 
    /// <param name="max">The maximum allowed value.</param>
	///
	/// <exception cref="WarningException">
	/// <pr>str</pr> isn't a decimal value, or isn't within the specified range.
	/// </exception>
    /// 
    decimal ParseDecimal(string str, decimal min, decimal max)
    {
		// parse <str> into <result>
        decimal result;
        try
        {
            result = decimal.Parse(str, CultureInfo.InvariantCulture);
        }
        catch (Exception ex)
        {
            if ((ex is FormatException) || (ex is OverflowException))
                throw new WarningException("Expected a number, got \"{0}\"", str);
            else
                throw;
        }

		// check that <result> is within the specified range
        if ((result < min) || (result > max))
        {
            throw new WarningException(
				"Expected a number in the range {0} to {1} (inclusive), got {2}",
                min, max, result);
        }

        return result;
    }

    ///////////////////////////////////////////////////////////////////////////////////////////////
    // Nested Types
    //

    /// <summary>
	/// Indicates a warning that should be displayed to the user.
    /// </summary>
	///
    class WarningException : Exception
    {
        /// <summary>
        /// Initializes an instance of this class.
        /// </summary>
        ///
        /// <param name="format">The format string for formatting the warning message.</param>
        ///
        /// <param name="args">Formatting arguments.  If no arguments are provided, <pr>format</pr>
		/// 	is returned as-is, without formatting.</param>
        /// 
        internal WarningException(string format, params object[] args) :
            base(Util.Format(format, args))
	    {
	    }
    }
}

/// <summary>
/// Holds the result of <r>AspHardware.GetFullStatus</r>.
/// </summary>
///
/// <remarks>
/// NOTE: This was tested with version 0.98 firmware, and with that firmware master decibel levels
/// appear to be integers, not in 1.5 steps as documented for newer hardware.  So, this class
/// supports arbitrary decimal decibel levels to attempt to cover all versions of firmware.
/// </remarks>
///
public class AspGetFullStatusReply
{
    /// <summary>
	/// The gain levels at each input/output crosspoint, as reported by the hardware.  Element
	/// <c>[i][j]</c> is decibel gain level of the crosspoint where input <c>i + 1</c> is connected
	/// to output <c>j + 1</c>.  An element contains -1 if there's no connection.
    /// </summary>
    public decimal[][] CrosspointGains { get; set; }

    /// <summary>
	/// The gain levels of output, as reported by the hardware.  Element <c>[i]</c> is the decibel
	/// gain level of output <c>i + 1</c>, or -1 for mute.
    /// </summary>
    public decimal[] OutputGains { get; set; }
}

/// <summary>
/// Holds the result of <r>AspHardware.SetOutputGain</r>.
/// </summary>
///
[DebuggerDisplay("OutputGain = {OutputGain}")]
public class AspSetOutputGainReply
{
    /// <summary>
	/// The output gain level as reported by the hardware.  For example, "MASTER=1,3.5" appears to
	/// report back "MASTER=1,3" (i.e. gain actually set to 3 decibels), in which case
	/// <r>OutputGain</r> is 3.
    /// </summary>
	public decimal OutputGain;
}

/// <summary>
/// Holds the result of <r>AspHardware.SetCrosspointGain</r>.
/// </summary>
[DebuggerDisplay("CrosspointGain = {CrosspointGain}")]
public class AspSetCrosspointGainReply
{
    /// <summary>
	/// The crosspoint gain level as reported by the hardware.  This may differ from the requested
	/// gatin level due to rounding.
    /// </summary>
	public decimal CrosspointGain;
}

/// <summary>
/// Arguments of the <r>AspHardware.CommandFailed</r> event.
/// </summary>
///
[DebuggerDisplay("Command = {Command}")]
public class AspHardwareCommandFailedEventArgs : EventArgs
{
    /// <summary>
    /// Gets the command that failed.
    /// </summary>
    public string Command { get; internal set; }
}

/// <summary>
/// Arguments of the <r>AspHardware.NotificationsReceived</r> event.
/// </summary>
///
[DebuggerDisplay("Count = {Notifications.Count}")]
public class AspHardwareNotificationsReceivedEventArgs : EventArgs
{
    /// <summary>
    /// Gets the notifications that were received.
    /// </summary>
    public ICollection<string> Notifications { get; internal set; }
}

/// <summary>
/// Arguments of the <r>AspHardware.WarningsReported</r> event.
/// </summary>
///
[DebuggerDisplay("Count = {Messages.Count}")]
public class AspHardwareWarningsReportedEventArgs : EventArgs
{
    /// <summary>
    /// Gets the warnings that were reported.
    /// </summary>
    public ICollection<string> Messages { get; internal set; }
}

}

